package com.nulldev.util.web.YouTubeExtractor.extractors.ytiapi_rolling;

import static com.nulldev.util.web.YouTubeExtractor.YouTubeExtractorConstants.PAGE_CL;
import static com.nulldev.util.web.YouTubeExtractor.YouTubeExtractorConstants.PAGE_LABEL;
import static com.nulldev.util.web.YouTubeExtractor.YouTubeExtractorConstants.PAGE_STS;
import static com.nulldev.util.web.YouTubeExtractor.YouTubeExtractorConstants.UTC_OFFSET;
import static com.nulldev.util.web.YouTubeExtractor.YouTubeExtractorConstants.VARIANTS_CHECKSUM;
import static com.nulldev.util.web.YouTubeExtractor.extractors.util.SharedExtractorUtil.YTX_GET_IDENTIFIER;
import static com.nulldev.util.web.YouTubeExtractor.extractors.util.SharedExtractorUtil.getHeatmap;
import static com.nulldev.util.web.YouTubeExtractor.extractors.util.SharedExtractorUtil.getLikeCountString;
import static com.nulldev.util.web.YouTubeExtractor.extractors.util.SharedExtractorUtil.isDVR;
import static com.nulldev.util.web.YouTubeExtractor.util.YouTubeExtractorUtil.getYTContinuation;
import static com.nulldev.util.web.YouTubeExtractor.util.YouTubeExtractorUtil.getYTString;
import static com.nulldev.util.web.YouTubeExtractor.util.YouTubeExtractorUtil.isYTMusicVideo;

import java.io.Reader;
import java.net.NoRouteToHostException;
import java.net.URL;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.TimeUnit;

import com.nulldev.util.JVM.Arguments;
import com.nulldev.util.VariableAPI.ArrayUtils;
import com.nulldev.util.VariableAPI.MathUtil.TimeUtil;
import com.nulldev.util.VariableAPI.StringsUtil;
import com.nulldev.util.VariableAPI.util.strings.splitters.Splitters;
import com.nulldev.util.concurrency.threadIt.v4.asyncTasks.AsyncTaskCallback;
import com.nulldev.util.concurrency.threadIt.v4.emapi.ExecutorManager;
import com.nulldev.util.concurrency.threadIt.v4.emapi.IExecutorManager;
import com.nulldev.util.concurrency.threadIt.v4.emapi.cntft.ControllableFutureTask;
import com.nulldev.util.concurrency.threadIt.v4.emapi.uft.Executable;
import com.nulldev.util.data.Variables;
import com.nulldev.util.data.variable;
import com.nulldev.util.data.Arrays.arrays.FastLists;
import com.nulldev.util.data.Arrays.maps.FastMaps;
import com.nulldev.util.data.Date.Dates;
import com.nulldev.util.io.IOUtils;
import com.nulldev.util.languages.json.uniapi.UniJsonIterableValue;
import com.nulldev.util.languages.json.uniapi.UniJsonValue;
import com.nulldev.util.logging.Logger;
import com.nulldev.util.logging.LoggerFactory;
import com.nulldev.util.web.HttpClient.HttpClient;
import com.nulldev.util.web.HttpClient.HttpMethod;
import com.nulldev.util.web.HttpClient.HttpResponse;
import com.nulldev.util.web.URLUtils.Entry;
import com.nulldev.util.web.URLUtils.URLUtils;
import com.nulldev.util.web.URLUtils.fuckTracking.FuckTracking;
import com.nulldev.util.web.YouTubeExtractor.YouTubeExtractor;
import com.nulldev.util.web.YouTubeExtractor.YouTubeExtractor.ABIFeatures;
import com.nulldev.util.web.YouTubeExtractor.YouTubeExtractor.ExtractorType;
import com.nulldev.util.web.YouTubeExtractor.YouTubeExtractorConstants;
import com.nulldev.util.web.YouTubeExtractor.advertising.YTEAdvertisingMetadata;
import com.nulldev.util.web.YouTubeExtractor.advertising.internal.YTEAdvertisingMetadataExtractor;
import com.nulldev.util.web.YouTubeExtractor.clarifications.Clarification;
import com.nulldev.util.web.YouTubeExtractor.comments.YTComments.YTCommentInitData;
import com.nulldev.util.web.YouTubeExtractor.endscreen.EndscreenEntry;
import com.nulldev.util.web.YouTubeExtractor.endscreen.EndscreenEntry.EntryType;
import com.nulldev.util.web.YouTubeExtractor.extractors.IYouTubeExtractor;
import com.nulldev.util.web.YouTubeExtractor.extractors.util.SharedExtractionFunctions;
import com.nulldev.util.web.YouTubeExtractor.extractors.util.SharedExtractorUtil;
import com.nulldev.util.web.YouTubeExtractor.extractors.util.YTAlternativeMetadataType;
import com.nulldev.util.web.YouTubeExtractor.extractors.util.YTIAPIClientType;
import com.nulldev.util.web.YouTubeExtractor.extractors.util.YTIAPIClientType.YTIAPIClientEnums;
import com.nulldev.util.web.YouTubeExtractor.formats.AudioCodec;
import com.nulldev.util.web.YouTubeExtractor.formats.FileType;
import com.nulldev.util.web.YouTubeExtractor.formats.Format;
import com.nulldev.util.web.YouTubeExtractor.formats.VideoCodec;
import com.nulldev.util.web.YouTubeExtractor.items.YouTubeStoryboardParser;
import com.nulldev.util.web.YouTubeExtractor.items.YouTubeStoryboardParser.YouTubeStoryboard;
import com.nulldev.util.web.YouTubeExtractor.items.YouTubeVideoHeatmap;
import com.nulldev.util.web.YouTubeExtractor.metadata.YouTubeMetadata;
import com.nulldev.util.web.YouTubeExtractor.metadata.YouTubeMetadata.DRMMetadata;
import com.nulldev.util.web.YouTubeExtractor.metadata.YouTubeMetadata.PlaybackMode;
import com.nulldev.util.web.YouTubeExtractor.metadata.YouTubeMetadata.ThumbnailSizeOption;
import com.nulldev.util.web.YouTubeExtractor.playlist.Playlist;
import com.nulldev.util.web.YouTubeExtractor.playlist.Playlist.PlaylistEntry;
import com.nulldev.util.web.YouTubeExtractor.signatures.extractor.Extractor;
import com.nulldev.util.web.YouTubeExtractor.subtitles.YouTubeCaptions;
import com.nulldev.util.web.YouTubeExtractor.util.YTRequestObject;
import com.nulldev.util.web.YouTubeExtractor.video.ExtractedVideo;
import com.nulldev.util.web.YouTubeExtractor.video.ExtractionResult;
import com.nulldev.util.web.YouTubeExtractor.video.YouTubeVideo;
import com.nulldev.util.web.YouTubeExtractor.video.YouTubeVideo.FormatType;

public class YouTubeIAPIExtractorRolling extends IYouTubeExtractor {

	private static String generateURL(final String videoID, final YTIAPIClientType clientType, final Map<String, Object> arguments) {
		final String API_KEY = clientType.contains(YTIAPIClientEnums.FallbackAPIKey) ? clientType.get(YTIAPIClientEnums.FallbackAPIKey)
				: YouTubeExtractorConstants.API_KEY;
		final StringBuilder b = new StringBuilder(
				arguments.containsKey(MAP_TYPE_USE_EXPERIMENTAL_BACKEND_FLAG) ? "https://staging-youtubei.sandbox.googleapis.com/youtubei/v1/player"
						: "https://www.youtube.com/youtubei/v1/player");
		b.append("?key=" + API_KEY);
		if (arguments.containsKey(MAP_TYPE_ENABLE_CONTROVERSY_BYPASS)) {
			b.append("&bpctr=");
			b.append(System.currentTimeMillis());
		}
		b.append("&prettyPrint=false");
		return b.toString();
	}

	private static String generateURL_NEXT(final String videoID, final YTIAPIClientType clientType, final Map<String, Object> arguments) {
		final String API_KEY = clientType.contains(YTIAPIClientEnums.FallbackAPIKey) ? clientType.get(YTIAPIClientEnums.FallbackAPIKey)
				: YouTubeExtractorConstants.API_KEY;
		final StringBuilder b = new StringBuilder(
				arguments.containsKey(MAP_TYPE_USE_EXPERIMENTAL_BACKEND_FLAG) ? "https://staging-youtubei.sandbox.googleapis.com/youtubei/v1/next"
						: "https://www.youtube.com/youtubei/v1/next");
		b.append("?key=" + API_KEY);
		if (arguments.containsKey(MAP_TYPE_ENABLE_CONTROVERSY_BYPASS)) {
			b.append("&bpctr=");
			b.append(System.currentTimeMillis());
		}
		b.append("&prettyPrint=false");
		return b.toString();
	}

	/**
	 * NOTE: This will disable related and other things, thanks YouTube. <br>
	 * Maybe you should cater to the user next time.
	 */
	private static final boolean ENABLE_COMMENTS_FOR_AGE_GATE = Arguments.hasArgument("--YTI.enableAgeGateComments");

	/**
	 * This currently gives a different date (UI version) instead of YT player
	 * version.
	 */
	private static final boolean ENABLE_CLIENT_VERSION_CHECK = Arguments.hasArgument("--YTI.enableClientVersionCheck");

	private static final IExecutorManager td = ExecutorManager.get();
	private static final Logger logger = LoggerFactory.getLoggerD(true, true);
	public static final String VERSION = "0.1.3";

	private class ExtractedRequest {
		public final String result;
		public final String cookiePayload;
		public final Executable<HttpResponse<Reader>> xNext;

		public ExtractedRequest(final String result, final String cookiePayload, final Executable<HttpResponse<Reader>> xNext) {
			this.result = result;
			this.cookiePayload = cookiePayload;
			this.xNext = xNext;
		}
	}

	@Override
	public Executable<ExtractedVideo> generateTask(final Map<String, Object> arguments) {
		if (!isValidMap(arguments))
			throw new IllegalArgumentException("Invalid map: " + arguments);
		final String videoID = (String) arguments.get(MAP_TYPE_VIDEO_ID);
		if (!isValidVideoID(videoID))
			throw new IllegalArgumentException("Invalid video ID: " + videoID);
		final YTIAPIClientType clientType;
		if (arguments.containsKey(MAP_TYPE_SOLVED_SAPISID_HASH)) {
			clientType = YTIAPIClientType.WEB_YT_CREATOR;
		} else if (arguments.containsKey(MAP_TYPE_USE_CLIENT_TYPE))
			clientType = (YTIAPIClientType) arguments.get(MAP_TYPE_USE_CLIENT_TYPE);
		else
			clientType = YTIAPIClientType.WEB;
		if (clientType.contains(YTIAPIClientEnums.RequiresSAPISID) && !arguments.containsKey(MAP_TYPE_SOLVED_SAPISID_HASH))
			throw new IllegalArgumentException("Cannot use client type " + clientType + " without an account hash.");
		if (clientType.contains(YTIAPIClientEnums.RequiresSAPISID) && !arguments.containsKey(MAP_TYPE_SOLVED_ID_TOKEN))
			throw new IllegalArgumentException("Cannot use client type " + clientType + " without an id token.");
		logger.debug("Using clientType: " + clientType);
		final boolean fallenBack = arguments.containsKey(MAP_TYPE_HAS_FALLEN_BACK);
		final boolean enable_cv9 = clientType.contains(YTIAPIClientEnums.SuggestsV9Cipher) && !isEnabled(arguments, MAP_TYPE_DISABLE_V9_DECIPHERING);
		final boolean keep_ls_formats = isEnabled(arguments, MAP_TYPE_LIVESTREAMS_KEEP_RAW_STREAMS);
		final boolean override_second_resp = arguments.containsKey(MAP_TYPE_DEBUG_OVERRIDE_SECOND_RESPONSE)
				&& arguments.get(MAP_TYPE_DEBUG_OVERRIDE_SECOND_RESPONSE) != null
				&& arguments.get(MAP_TYPE_DEBUG_OVERRIDE_SECOND_RESPONSE) instanceof UniJsonValue;
		final boolean contentChecksOk = fallenBack || isEnabled(arguments, MAP_TYPE_ENABLE_CONTROVERSY_BYPASS);
		final boolean disablePriorityPoint = isEnabled(arguments, MAP_TYPE_HEATMAP_DISABLE_PRIORITY_POINT);

		final String CLIENT_VERSION = YTIAPIClientType.getClientVersion(clientType);

		return td.makeTask(() -> {
			final List<YouTubeVideo> results = FastLists.list(OUTPUT_LIST_DEFAULT_SIZE);
			final variable<Integer> _index = new variable<Integer>(-1);
			final Object _result = td.submit(() -> {
				logger.debug("=> Generating URL...");
				final URL url = URLUtils.parseURL(generateURL(videoID, clientType, arguments));
				final URL url_next = URLUtils.parseURL(generateURL_NEXT(videoID, clientType, arguments));
				logger.debug("URL: " + url);
				logger.debug("URL_NEXT: " + url_next);
				Entry[] _args = YouTubeExtractor.DEFAULT_COOKIES;
				if (isEnabled(arguments, MAP_TYPE_SEND_FAKE_COOKIES_COMPAT) || isEnabled(arguments, MAP_TYPE_ENFORCE_PRIVACY_MODE_COMPAT)) {
					_args = ArrayUtils.add(_args, new Entry("Cookie", StringsUtil.randomString(64)));
				}
				if (isEnabled(arguments, MAP_TYPE_EMULATE_INSECURE_PROXY_COMPAT) || isEnabled(arguments, MAP_TYPE_ENFORCE_PRIVACY_MODE_COMPAT)) {
					_args = ArrayUtils.addAll(_args, FuckTracking.protect());
				}
				if (arguments.containsKey(MAP_TYPE_PASSTHROUGH_COOKIES)) {
					_args = new Entry[]
						{ new Entry("Cookie", (String) arguments.get(MAP_TYPE_PASSTHROUGH_COOKIES)) };
				}
				String cookiePayload = "";
				final String API_KEY = clientType.contains(YTIAPIClientEnums.FallbackAPIKey) ? clientType.get(YTIAPIClientEnums.FallbackAPIKey)
						: YouTubeExtractorConstants.API_KEY;
				final String forcedUserAgent = clientType.contains(YTIAPIClientEnums.CustomUserAgent) ? clientType.get(YTIAPIClientEnums.CustomUserAgent)
						: URLUtils.STANDARD_USERAGENT;
				final boolean NO_BROWSER_VERSION = clientType.contains(YTIAPIClientEnums.DisableBrowserVersion);
				final String YT_REQUEST_PLATFORM = clientType.getOrDefault("YT_REQUEST_PLATFORM", YTRequestObject.DEFAULT_REQUEST_PLATFORM);
				try {
					final UniJsonValue o = YTRequestObject.makeYTRequestObject(FastMaps.fixedUniMap(new Object[][]
						{
								{ YTRequestObject.YT_REQUEST_VIDEO_ID, videoID },
								{ YTRequestObject.YT_REQUEST_CLIENT_TYPE, clientType },
								{ YTRequestObject.YT_REQUEST_CLIENT_VERSION, CLIENT_VERSION },
								{ YTRequestObject.YT_REQUEST_NO_BROWSER_VERSION, NO_BROWSER_VERSION },
								{ YTRequestObject.YT_REQUEST_CUSTOM_PLATFORM, YT_REQUEST_PLATFORM },
								{ YTRequestObject.YT_ENABLE_PLAYBACK_CONTEXT, true },
								{ YTRequestObject.YT_ENABLE_PLAYER_TYPE, true },
								{ YTRequestObject.YT_REQUEST_USER_AGENT, forcedUserAgent },
								{ YTRequestObject.YT_ENABLE_FALLEN_BACK_MODE, fallenBack } }));
					o.add("videoId", videoID);
					if (arguments.containsKey(MAP_TYPE_PLAYLIST_STR))
						o.add("playlistId", arguments.get(MAP_TYPE_PLAYLIST_STR));
					if (arguments.containsKey(MAP_TYPE_PLAYLIST_INDEX)) {
						o.add("playlistIndex", arguments.get(MAP_TYPE_PLAYLIST_INDEX));
						_index.set((int) arguments.get(MAP_TYPE_PLAYLIST_INDEX));
					}
					o.add("contentCheckOk", contentChecksOk);
					o.add("racyCheckOk", contentChecksOk);
					if (clientType.contains(YTIAPIClientEnums.SystemSendParamObj))
						o.add("params", "CgIQBg");
					/* TODO: Redirect ANDROID calls to baltimore. */
					logger.debug("=> Downloading metadatas...");
					final Executable<HttpResponse<Reader>> xNext = override_second_resp ? null
							: HttpClient.getReader().url(url_next).timeout(Duration.ofSeconds(20)).forceHeader("User-Agent", forcedUserAgent).headers(_args)
									.header("Content-Type", "application/json").header("X-Goog-Api-Key", API_KEY)
									.header(new Entry("Referrer", "https://www.youtube.com/"))
									.header("X-YouTube-Client-Name", clientType.get(YTIAPIClientEnums.ContextClientName))
									.header("X-YouTube-Client-Version", CLIENT_VERSION).header("X-YouTube-Page-CL", PAGE_CL)
									.header("X-YouTube-Page-Label", PAGE_LABEL).header("X-YouTube-STS", PAGE_STS).header("X-YouTube-Utc-Offset", UTC_OFFSET)
									.header("X-YouTube-Variants-Checksum", VARIANTS_CHECKSUM)
									.header("Authorization",
											arguments.containsKey(MAP_TYPE_SOLVED_SAPISID_HASH) ? (String) arguments.get(MAP_TYPE_SOLVED_SAPISID_HASH) : null)
									.header("X-Origin",
											clientType.contains(YTIAPIClientEnums.CustomHostHeader) ? clientType.get(YTIAPIClientEnums.CustomHostHeader) : null)
									.header("Origin",
											clientType.contains(YTIAPIClientEnums.CustomHostHeader) ? clientType.get(YTIAPIClientEnums.CustomHostHeader) : null)
									.header("X-Youtube-Identity-Token",
											arguments.containsKey(MAP_TYPE_SOLVED_ID_TOKEN) ? (String) arguments.get(MAP_TYPE_SOLVED_ID_TOKEN) : null)
									.createTaskAndRun(HttpMethod.POST, o.toString());
					final HttpResponse<String> x = HttpClient.getString().url(url).timeout(Duration.ofSeconds(20)).forceHeader("User-Agent", forcedUserAgent)
							.headers(_args).header("Content-Type", "application/json").header("X-Goog-Api-Key", API_KEY)
							.header(new Entry("Referrer", "https://www.youtube.com/"))
							.header("X-YouTube-Client-Name", clientType.get(YTIAPIClientEnums.ContextClientName))
							.header("X-YouTube-Client-Version", CLIENT_VERSION).header("X-YouTube-Page-CL", PAGE_CL).header("X-YouTube-Page-Label", PAGE_LABEL)
							.header("X-YouTube-STS", PAGE_STS).header("X-YouTube-Utc-Offset", UTC_OFFSET)
							.header("X-YouTube-Variants-Checksum", VARIANTS_CHECKSUM)
							.header("Authorization",
									arguments.containsKey(MAP_TYPE_SOLVED_SAPISID_HASH) ? (String) arguments.get(MAP_TYPE_SOLVED_SAPISID_HASH) : null)
							.header("X-Origin",
									clientType.contains(YTIAPIClientEnums.CustomHostHeader) ? clientType.get(YTIAPIClientEnums.CustomHostHeader) : null)
							.header("X-Youtube-Identity-Token",
									arguments.containsKey(MAP_TYPE_SOLVED_ID_TOKEN) ? (String) arguments.get(MAP_TYPE_SOLVED_ID_TOKEN) : null)
							.create(HttpMethod.POST, o.toString());
					for (final String cookie : x.cookies()) {
						cookiePayload += Splitters.splitString(cookie, ";")[0] + ";";
					}
					if (cookiePayload.endsWith(";")) {
						cookiePayload = cookiePayload.substring(0, cookiePayload.length() - 1);
					}
					return new ExtractedRequest(StringsUtil.replace(x.dataThenClose(), "\u0008", ""), cookiePayload, xNext);
				} catch (Throwable e) {
					if (e instanceof NoRouteToHostException) {
						return new ExtractedVideo(null, null, ExtractionResult.FAILED).setMessage("FAILED_STAGE_UNABLE_TO_GET_METADATA")
								.setYTMessage("No route to youtube.com was found, this might be the internet.");
					} else {
						logger.error(e);
						return new ExtractedVideo(null, null, ExtractionResult.FAILED).setMessage("FAILED_STAGE_UNABLE_TO_GET_METADATA")
								.setYTMessage("Hit the following exception: " + e.getMessage());
					}
				}
			}).waitTillCompletion();
			if (_result == null)
				throw new NullPointerException("_result == null!");
			if (_result instanceof ExtractedVideo) {
				return ((ExtractedVideo) _result);
			}
			final String cookiePayload = ((ExtractedRequest) _result).cookiePayload;
			final String result = ((ExtractedRequest) _result).result;
			final ControllableFutureTask<UniJsonValue> xNextJSON = new ControllableFutureTask<UniJsonValue>();
			if (!override_second_resp) {
				((ExtractedRequest) _result).xNext.addCallback(new AsyncTaskCallback<HttpResponse<Reader>>() {

					@Override
					public void onError(final Throwable t) {
						xNextJSON.fail(t);
					}

					@Override
					public void onCompletion(final HttpResponse<Reader> result) {
						try {
							xNextJSON.complete(UniJsonValue.parse(result.data()));
						} catch (Exception ex) {
							logger.error("Failed to parse xNextJSON!", ex);
							xNextJSON.fail(ex);
						} finally {
							IOUtils.closeQuietly(result);
						}
					}

				});
			} else if (override_second_resp) {
				xNextJSON.complete((UniJsonValue) arguments.get(MAP_TYPE_DEBUG_OVERRIDE_SECOND_RESPONSE));
			}
			if (arguments.containsKey(MAP_TYPE_ENABLE_DEBUG_API_RESPONSE_PRINT) && !override_second_resp)
				xNextJSON.addCallback(new AsyncTaskCallback<UniJsonValue>() {

					@Override
					public void onCompletion(final UniJsonValue result) {
						result.println();
					}

					@Override
					public void onError(final Throwable t) {
						return;
					}

				});
			UniJsonValue _objs = UniJsonValue.parse(result);
			YTAlternativeMetadataType useAlternativeMetadata = YTAlternativeMetadataType.NONE;
			long alternateMetadataStartTime = -1L;
			if (_objs.contains("playabilityStatus")
					&& !ArrayUtils.contains(NON_TRAILER_PLAYABILITY_STATUSES, _objs.get("playabilityStatus").getString("status", "null"))) {
				if (_objs.get("playabilityStatus").contains("errorScreen")
						&& _objs.get("playabilityStatus").get("errorScreen").contains("playerLegacyDesktopYpcTrailerRenderer")) {
					useAlternativeMetadata = YTAlternativeMetadataType.LEGACY_TRAILER_RENDERER;
					_objs = _objs.mget(
							"playabilityStatus.errorScreen.playerLegacyDesktopYpcTrailerRenderer.ypcTrailer.ypcTrailerRenderer.unserializedPlayerResponse");
					logger.debug("[alternativeMetadata] Alternative metadata found, using that instead.");
				} else if (_objs.get("playabilityStatus").contains("errorScreen")
						&& _objs.get("playabilityStatus").get("errorScreen").contains("ypcTrailerRenderer")) {
					useAlternativeMetadata = YTAlternativeMetadataType.TRAILER_RENDERER_V2;
					if (_objs.get("playabilityStatus").contains("liveStreamability")) {
						final UniJsonValue lsy = _objs.mget("playabilityStatus.liveStreamability.liveStreamabilityRenderer.offlineSlate");
						if (lsy != null && lsy.contains("liveStreamOfflineSlateRenderer")) {
							alternateMetadataStartTime = lsy.get("liveStreamOfflineSlateRenderer").getLong("scheduledStartTime", -1);
						} else
							logger.warn("Unknown livestreamability: " + lsy);
					}
					_objs = _objs.mget("playabilityStatus.errorScreen.ypcTrailerRenderer.unserializedPlayerResponse");
					logger.debug("[alternativeMetadata] Alternative metadata found, using that instead.");
				} else if (ArrayUtils.contains(NON_TRAILER_PLAYABILITY_STATUSES, _objs.get("playabilityStatus").getString("status", "null"))) {
				} else {
					logger.debug(_objs.get("playabilityStatus"));
					return new ExtractedVideo(null, null, ExtractionResult.FAILED).setMessage("FAILED_STAGE_UNABLE_TO_GET_METADATA")
							.setYTMessage("YouTube reported the following playability status: " + _objs.get("playabilityStatus").getString("status", "null"));
				}
			}
			final UniJsonValue objs = _objs;
			if (arguments.containsKey(MAP_TYPE_ENABLE_DEBUG_API_RESPONSE_PRINT))
				objs.println();
			final UniJsonValue playabilityStatus = objs.get("playabilityStatus");
			if (playabilityStatus != null && playabilityStatus.contains("status")
					&& !ArrayUtils.contains(ALLOWED_PLAYABILITY_STATUSES, playabilityStatus.getString("status", "OK"))) {
				final String status = playabilityStatus.getString("status", "OK");
				if ((int) getFlag(FLAG_EXTRACTOR_SUPPORTS_AGE_GATE) != FLAG_TRUE && status.equalsIgnoreCase("LOGIN_REQUIRED")) {
					return YouTubeExtractor.get(ExtractorType.AGE_GATE).generateTask(arguments).run_st();
				} else {
					final boolean canFallback = arguments.containsKey(MAP_TYPE_FALLBACK_LAYER)
							? (arguments.get(MAP_TYPE_FALLBACK_LAYER) instanceof Integer && ((int) arguments.get(MAP_TYPE_FALLBACK_LAYER) + 1) < 2)
							: true;
					final int currentFBLayer = arguments.containsKey(MAP_TYPE_FALLBACK_LAYER) && arguments.get(MAP_TYPE_FALLBACK_LAYER) instanceof Integer
							? ((int) arguments.get(MAP_TYPE_FALLBACK_LAYER))
							: 0;
					if (status.equalsIgnoreCase("LOGIN_REQUIRED") && !fallenBack && canFallback) {
						final Map<String, Object> newArgs = FastMaps.map(arguments);
						newArgs.put(MAP_TYPE_HAS_FALLEN_BACK, true);
						newArgs.put(MAP_TYPE_USE_CLIENT_TYPE,
								currentFBLayer == 0 ? YTIAPIClientType.TVHTML5_SIMPLE_EMBEDDED_PLAYER : YTIAPIClientType.EMBEDDED_ANDROID);
						if (currentFBLayer == 0 && ENABLE_COMMENTS_FOR_AGE_GATE)
							newArgs.put(MAP_TYPE_DEBUG_OVERRIDE_SECOND_RESPONSE, xNextJSON.waitFor(5, TimeUnit.SECONDS));
						if (!arguments.containsKey(MAP_TYPE_FALLBACK_LAYER))
							newArgs.put(MAP_TYPE_FALLBACK_LAYER, 0);
						else
							newArgs.put(MAP_TYPE_FALLBACK_LAYER, currentFBLayer + 1);
						return generateTask(newArgs).run_st();
					} else if (status.equalsIgnoreCase("LOGIN_REQUIRED") && !fallenBack && !canFallback) {
						return YouTubeExtractor.get("YTE.third_party.simple_age_restriction_bypass").generateTask(arguments).run_st();
					} else if (status.equalsIgnoreCase("CONTENT_CHECK_REQUIRED") && !fallenBack && canFallback) {
						final Map<String, Object> newArgs = FastMaps.map(arguments);
						newArgs.put(MAP_TYPE_HAS_FALLEN_BACK, true);
						newArgs.put(MAP_TYPE_ENABLE_CONTROVERSY_BYPASS, true);
						if (!arguments.containsKey(MAP_TYPE_FALLBACK_LAYER))
							newArgs.put(MAP_TYPE_FALLBACK_LAYER, 0);
						else
							newArgs.put(MAP_TYPE_FALLBACK_LAYER, currentFBLayer + 1);
						// There seems to be no way to apply "bpctr" to YTIAPI, fallback to NFEE-rolling
						// for now.
						return YouTubeExtractor.get("YouTubeExtractor.NFEE_rolling_release").generateTask(newArgs).run_st();
					} else
						return new ExtractedVideo(null, null, fallenBack ? ExtractionResult.FAILED_AGE_RESTRICTED : ExtractionResult.FAILED)
								.setMessage("FAILED_STAGE_GET_METADATA_CORE").setYTMessage("YouTube returned playability status " + status + ".");
				}
			}
			final UniJsonValue metadataCore = objs;
			if (metadataCore == null || metadataCore.isNull()) {
				((ExtractedRequest) _result).xNext.addCallback(new AsyncTaskCallback<HttpResponse<Reader>>() {

					@Override
					public void onCompletion(final HttpResponse<Reader> result) {
						IOUtils.closeQuietly(result);
					}
				});
				return new ExtractedVideo(null, null, ExtractionResult.FAILED).setMessage("FAILED_STAGE_GET_METADATA_CORE")
						.setYTMessage("Either this video is private or was removed.");
			}
			logger.debug("=> Accquiring decipherer.");
			final Executable<Extractor> e = td.submitLP(() -> {
				try {
					final UniJsonValue xresult = xNextJSON.waitFor(10, TimeUnit.SECONDS);
					if (xresult.contains("responseContext") && xresult.get("responseContext").contains("serviceTrackingParams")
							&& clientType == YTIAPIClientType.WEB && ENABLE_CLIENT_VERSION_CHECK) {
						final UniJsonValue serviceTrackingParams = xresult.get("responseContext").get("serviceTrackingParams");
						final UniJsonValue csi = (UniJsonValue) serviceTrackingParams.asArray().stream().filter(obj -> ((UniJsonValue) obj).contains("service"))
								.filter(obj -> ((UniJsonValue) obj).getString("service").equalsIgnoreCase("CSI")).findFirst().orElse(null);
						if (csi == null) {
							logger.warn("[decipherer] Failed to get CSI!");
							return YouTubeExtractor.extractor.getAuto();
						}
						final UniJsonValue params = csi.get("params");
						final UniJsonValue cver = (UniJsonValue) params.asArray().stream().filter(obj -> ((UniJsonValue) obj).contains("key"))
								.filter(obj -> ((UniJsonValue) obj).getString("key").equalsIgnoreCase("cver")).findFirst().orElse(null);
						final String clientVersion = cver == null ? null : cver.getString("value");
						logger.debug("Current client version: " + clientVersion);
						if (clientVersion == null) {
							logger.warn("[decipherer] Failed to get clientVersion!");
							return YouTubeExtractor.extractor.getAuto();
						} else if (!YouTubeExtractorConstants.CLIENT_VERSION.equalsIgnoreCase(clientVersion)) {
							logger.warn("Data mismatch, Reaccquiring decipherer! (LOCAL: " + YouTubeExtractorConstants.CLIENT_VERSION + ", GOT: "
									+ clientVersion + ")");
							return YouTubeExtractor.extractor.reaccquire();
						} else {
							return YouTubeExtractor.extractor.getAuto();
						}
					} else {
						return YouTubeExtractor.extractor.getAuto();
					}
				} catch (Exception ef) {
					throw new RuntimeException(ef);
				}
			});
			if (clientType.contains(YTIAPIClientEnums.RequiresSAPISID) && (metadataCore == null || metadataCore.isNull() || metadataCore.contains("error"))) {
				((ExtractedRequest) _result).xNext.addCallback(new AsyncTaskCallback<HttpResponse<Reader>>() {

					@Override
					public void onCompletion(final HttpResponse<Reader> result) {
						IOUtils.closeQuietly(result);
					}
				});
				return new ExtractedVideo(null, null, ExtractionResult.FAILED).setMessage("FAILED_STAGE_GET_METADATA_CORE")
						.setYTMessage("Failed to authenticate with YouTube.");
			}
			final UniJsonValue metadataObject = metadataCore.get("videoDetails");
			Variables.notNullE(metadataObject);
			final variable<Playlist> pl = new variable<Playlist>(null);
			final Executable<List<EndscreenEntry>> endscreen = td.submitLP(() -> {
				final List<EndscreenEntry> entries = FastLists.list();
				if (objs.hasValue("endscreen")) {
					for (final UniJsonValue _v : objs.get("endscreen").get("endscreenRenderer").get("elements").asArray()) {
						final UniJsonValue v = _v.get("endscreenElementRenderer");
						final String style = v.getString("style");
						if (style == null) {
							logger.error("Failed to get endscreen style.");
							continue;
						}
						if (style.equalsIgnoreCase("CHANNEL")) {
							continue; // Rejected implementation
						} else if (style.equalsIgnoreCase("PLAYLIST")) {
							try {
								final String fullURL = "/watch?v=" + v.get("endpoint").get("watchEndpoint").getString("videoId") + "&list="
										+ v.get("endpoint").get("watchEndpoint").getString("playlistId");
								entries.add(new EndscreenEntry(EntryType.PLAYLIST, fullURL, v.get("endpoint").get("watchEndpoint").getString("videoId"),
										getYTString(v.get("title")), getYTString(v.get("metadata"))));
							} catch (Exception ef) {
								logger.error("Failed to process PLAYLIST endscreen entry", ef);
								logger.info(v);
							}
						} else if (style.equalsIgnoreCase("VIDEO")) {
							try {
								final String fullURL = "/watch?v=" + v.get("endpoint").get("watchEndpoint").getString("videoId");
								entries.add(new EndscreenEntry(EntryType.VIDEO, fullURL, v.get("endpoint").get("watchEndpoint").getString("videoId"),
										getYTString(v.get("title")), getYTString(v.get("metadata"))));
							} catch (Exception ef) {
								logger.error("Failed to process VIDEO endscreen entry", ef);
								logger.info(v);
							}
						} else if (style.equalsIgnoreCase("WEBSITE")) {
							try {
								final String fullURL = StringsUtil.replace(v.get("endpoint").get("urlEndpoint").getString("url"), "https://www.youtube.com",
										"");
								final String thumbnail = v.get("image").get("thumbnails").get(0).getString("url");
								entries.add(new EndscreenEntry(EntryType.WEBSITE, fullURL, "null", getYTString(v.get("title")), getYTString(v.get("metadata")))
										.overrideImageURL(thumbnail));
							} catch (Exception ef) {
								logger.error("Failed to process WEBSITE endscreen entry", ef);
								logger.info(v);
							}
						} else {
							logger.warn("Ignored unknown endscreen style: " + style);
							logger.warn(v.toString());
						}
					}
				}
				return entries;
			});
			final Executable<YTEAdvertisingMetadata> advertisingMetadata = td.submitLP(() -> {
				if (!isEnabled(arguments, MAP_TYPE_ENABLE_ADVERTISING_METADATA_EXTRACTION))
					return null;
				try {
					return YTEAdvertisingMetadataExtractor.makeFromPlaybackObject(metadataCore, videoID);
				} catch (Exception ex) {
					logger.error("Failed to get advertising metadata!", ex);
					return null;
				}
			});
			final Executable<YouTubeMetadata> metadata = td.submitLP(() -> {
				final List<YouTubeCaptions> captions = FastLists.list();
				if (objs.contains("captions")) {
					// captions found
					final UniJsonValue ct = objs.getSafe("captions").getSafe("playerCaptionsTracklistRenderer").getIgnoreNull("captionTracks");
					if (ct == null) {
						logger.error("Failed to load captions, captionTracks was null."); // probably no subtitles or contributions are only enabled.
					} else {
						final UniJsonIterableValue captionArray = ct.asArray();
						if (!captionArray.isNull()) {
							for (final UniJsonValue _v : captionArray) {
								final UniJsonValue caption = _v;
								final String captionURL = caption.getString("baseUrl");
								final String captionText = StringsUtil._capitalizeString(getYTString(caption.get("name")));
								final String captionCode = caption.getString("languageCode");
								captions.add(new YouTubeCaptions(captionURL, captionText, captionCode));
							}
						}
					}
				}
				UniJsonValue keywords = UniJsonValue.array();
				try {
					keywords = metadataObject.get("keywords").asArray();
				} catch (Exception _e) {
				}
				if (keywords == null) {
					logger.warn("No keywords found, providing a blank array instead!");
					keywords = UniJsonValue.array();
				}
				final boolean is360 = /*
										 * result.contains("360°") ||
										 */ SharedExtractorUtil.is360(result);
				final YouTubeMetadata _metadata = new YouTubeMetadata(videoID, StringsUtil.replace(metadataObject.getString("title", "null"), "%3B", ";"),
						StringsUtil.replace(metadataObject.getString("author", "null"), "%3B", ";"), metadataObject.getString("channelId", "null"),
						metadataObject.getLong("viewCount", -1), metadataObject.getLong("lengthSeconds", -1), metadataObject.getDouble("averageRating", 5), -1,
						-1, is360, keywords.toArray(), captions);
				_metadata.setVR180(is360 && true); // FIXME: Proper soluton.
				_metadata.limitedExtraction(result.contains("verify_controversy"));
				if (metadataObject.contains("thumbnail")) {
					if (metadataObject.get("thumbnail").contains("thumbnails") && metadataObject.mget("thumbnail.thumbnails").isArray()) {
						int level = 0;
						for (final UniJsonValue v : metadataObject.mget("thumbnail.thumbnails").asArray()) {
							final String url = v.getString("url", null);
							if (url == null || url.isEmpty()) {
								logger.warn("Invalid thumbnail object: " + v);
								continue;
							}
							final int localLevel = ThumbnailSizeOption.getLevel(url);
							if (level < localLevel)
								level = localLevel;
						}
						_metadata.setThumbnailLevel(level);
					}
				}
				if (metadataCore.hasValue("microformat") && metadataCore.get("microformat").hasValue("playerMicroformatRenderer")) {
					try {
						final UniJsonValue mf = metadataCore.get("microformat").get("playerMicroformatRenderer");
						_metadata.getExtendedMetadata().uploadDate = mf.getString("uploadDate");
						_metadata.getExtendedMetadata().publishDate = mf.getString("publishDate");
						_metadata.getExtendedMetadata().isFamilyFriendly = mf.getBoolean("isFamilySafe");
					} catch (Exception ef) {
						logger.error("Failed to get extended data!", ef);
					}
				}
				if (metadataCore.contains("playerConfig") && metadataCore.get("playerConfig").contains("audioConfig")) {
					final UniJsonValue pc = metadataCore.get("playerConfig").get("audioConfig");
					_metadata.getExtendedMetadata().loudnessDbRaw = pc.getFloat("loudnessDb", 0);
					_metadata.getExtendedMetadata().perceptualLoudnessDb = pc.getFloat("perceptualLoudnessDb", 0);
					if (_metadata.getExtendedMetadata().loudnessDbRaw == 0) {
						_metadata.getExtendedMetadata().loudnessDbRaw = pc.getFloat("perceptualLoudnessDb", 0);
					}
				}
				_metadata.setMap(xNextJSON.waitTillCompletion().toString());
				_metadata.setDescription(StringsUtil.replaceEach(metadataObject.getString("shortDescription", "A description wasn't found."),
						DESCRIPTION_REPLACEMENTS_INPUT, DESCRIPTION_REPLACEMENTS_OUTPUT));
				if (isYTMusicVideo(metadataObject.getString("shortDescription", "A description wasn't found."))) {
					_metadata.setPlaybackMode(PlaybackMode.YT_MUSIC);
				}
				return _metadata;
			});
			if (metadataObject.hasValue("isUpcoming") && metadataObject.getBoolean("isUpcoming")) {
				logger.error("Failed -> This video isn't live yet!");
				String liveChatCont = null;
				try {
					if (clientType.contains(YTIAPIClientEnums.IsMobileContext)) {
						liveChatCont = xNextJSON.waitTillCompletion().get("engagementPanels").first().get("engagementPanelSectionListRenderer").get("content")
								.get("sectionListRenderer").get("contents").first().get("itemSectionRenderer").get("contents").first().get("liveChatRenderer")
								.get("continuations").asArray().get(0).get("reloadContinuationData").getString("continuation");
					} else {
						liveChatCont = xNextJSON.waitTillCompletion().get("contents").get("twoColumnWatchNextResults").get("conversationBar")
								.get("liveChatRenderer").get("continuations").asArray().get(0).get("reloadContinuationData").getString("continuation");
					}
					logger.debug("liveChatCont: " + liveChatCont);
				} catch (Exception ex) {
					logger.error("Failed to get liveChatCont!", ex);
					logger.debug(xNextJSON.waitTillCompletion());
				}
				logger.debug("metadata: " + metadata);
				logger.debug("endscreen: " + endscreen);
				try {
					final long time = objs.get("playabilityStatus").get("liveStreamability").get("liveStreamabilityRenderer").get("offlineSlate")
							.get("liveStreamOfflineSlateRenderer").getLong("scheduledStartTime", -1);
					final String times = TimeUtil.formatSecondsL(time - System.currentTimeMillis() / 1000);
					return new ExtractedVideo(results,
							metadata.waitTillCompletion().setContentStartTime((long) (time * 1000)).setEndscreenData(endscreen.waitTillCompletion()),
							ExtractionResult.FAILED).setMessage("ERROR_UPCOMING_STREAM")
							.setYTMessage("This video or stream will be live in " + times + " from now. (Stream Start: "
									+ Dates.time((long) (time * 1000)).toString() + ")")
							.setChatPayload(liveChatCont);
				} catch (Exception ex) {
					logger.error("Failed to get upcoming stream time!", ex);
					return new ExtractedVideo(results, metadata.waitTillCompletion().setEndscreenData(endscreen.waitTillCompletion()), ExtractionResult.FAILED)
							.setMessage("ERROR_UPCOMING_STREAM").setChatPayload(liveChatCont);
				}

			}
			if (objs.hasValue("streamingData")) {
				final UniJsonValue streamingData = objs.get("streamingData");
				final boolean isLiveStream = metadataCore.get("videoDetails").bool("isLiveContent", false);
				final boolean isDVR = isDVR(metadataCore);
				logger.debug("isLiveStream: " + isLiveStream + ", isDVR: " + isDVR + ", keep_ls_formats: " + keep_ls_formats);
				final boolean isTrueLiveStream = isLiveStream && !isDVR;
				final boolean isLowLatencyStream = metadataCore.get("videoDetails").bool("isLowLatencyLiveStream", false);
				String liveChatCont = null;
				if (isLiveStream) {
					try {
						if (clientType.contains(YTIAPIClientEnums.IsMobileContext)) {
							liveChatCont = xNextJSON.waitTillCompletion().get("engagementPanels").first().get("engagementPanelSectionListRenderer")
									.get("content").get("sectionListRenderer").get("contents").first().get("itemSectionRenderer").get("contents").first()
									.get("liveChatRenderer").get("continuations").asArray().get(0).get("reloadContinuationData").getString("continuation");
						} else {
							liveChatCont = xNextJSON.waitTillCompletion().get("contents").get("twoColumnWatchNextResults").get("conversationBar")
									.get("liveChatRenderer").get("continuations").asArray().get(0).get("reloadContinuationData").getString("continuation");
						}
						logger.debug("liveChatCont: " + liveChatCont);
					} catch (Exception ex) {
						if (streamingData.hasValue("hlsManifestUrl")) {
							logger.error("Failed to get liveChatCont!", ex);
							logger.debug(xNextJSON.waitTillCompletion());
						}
					}
				}
				if (streamingData.hasValue("hlsManifestUrl") && isLiveStream) {
					logger.debug("Parsing a live video...");
					final String hlsMetadata = streamingData.getString("hlsManifestUrl");
					final String dashManifestUrl = streamingData.getString("dashManifestUrl", null);
					if (isTrueLiveStream && !keep_ls_formats)
						results.clear();
					results.add(new YouTubeVideo(hlsMetadata, false, 0, new Format(0, FileType.M3U8, 30, 720, VideoCodec.H264, AudioCodec.AAC, -1, false))
							.setLivestream(true).setInternalProxy(true).setFormatType(FormatType.M3U8_STREAM));
					if (dashManifestUrl != null)
						results.add(new YouTubeVideo(dashManifestUrl, false, 0,
								new Format(0, FileType.UNKNOWN, 30, 720, VideoCodec.H264, AudioCodec.AAC, -1, false)).setLivestream(true).setInternalProxy(true)
								.setFormatType(FormatType.DASH_STREAM));
					if (!keep_ls_formats) {
						return new ExtractedVideo(results, metadata.waitTillCompletion().setLiveStream(true).setDVRStream(isDVR)
								.setLowLatencyStream(isLowLatencyStream).setEndscreenData(endscreen.waitTillCompletion()), ExtractionResult.SUCCESS)
								.setChatPayload(liveChatCont);
					} else {
						metadata.waitTillCompletion().setLiveStream(true).setDVRStream(isDVR).setLowLatencyStream(isLowLatencyStream);
					}
				} else if (streamingData.hasValue("hlsManifestUrl") && !isLiveStream) {
					final String hlsMetadata = streamingData.getString("hlsManifestUrl");
					final String dashManifestUrl = streamingData.getString("dashManifestUrl", null);
					results.add(new YouTubeVideo(hlsMetadata, false, 0, new Format(0, FileType.M3U8, 30, 720, VideoCodec.H264, AudioCodec.AAC, -1, false))
							.setLivestream(true).setInternalProxy(true).setFormatType(FormatType.M3U8_STREAM));
					if (dashManifestUrl != null)
						results.add(new YouTubeVideo(dashManifestUrl, false, 0,
								new Format(0, FileType.UNKNOWN, 30, 720, VideoCodec.H264, AudioCodec.AAC, -1, false)).setLivestream(true).setInternalProxy(true)
								.setFormatType(FormatType.DASH_STREAM));
				} else if (isLiveStream && !isDVR) {
					logger.warn("Didn't find a HLS or DASH stream, assuming this is a DVR...");
					metadata.waitTillCompletion().setLiveStream(false).setDVRStream(true).setLowLatencyStream(isLowLatencyStream);
				}
			}
			final boolean allowLateExtractions = isEnabled(arguments, MAP_TYPE_ENABLE_LATE_EXTRACTIONS);
			final variable<Boolean> isVR180 = new variable<Boolean>(false);
			final LinkedTransferQueue<Executable<YouTubeVideo>> queue = new LinkedTransferQueue<Executable<YouTubeVideo>>();
			if (objs.hasValue("streamingData")) {
				final UniJsonValue streamingData = objs.get("streamingData");
				if (streamingData.contains("formats")) {
					streamingData.get("formats").asArray().forEach((v) -> {
						SharedExtractionFunctions.generate_legacy_video(queue, results, v, e, isVR180, enable_cv9, true);
					});
				}
				if (streamingData.contains("adaptiveFormats")) {
					streamingData.get("adaptiveFormats").asArray().forEach((v) -> {
						SharedExtractionFunctions.generate_adaptive_video(queue, results, v, e, isVR180, enable_cv9, true);
					});
				}
				if (!allowLateExtractions) {
					final List<Executable<?>> ox = td.awaitQueueFor(10, TimeUnit.SECONDS, queue);
					if (!ox.isEmpty()) {
						logger.error("Failed to fully wait on queue handling!");
						logger.error("Failed by: ");
						ArrayUtils.printLln(ox);
					}
				}

				if (streamingData.contains("dashManifestUrl")) {
					results.add(
							new YouTubeVideo(streamingData.getString("dashManifestUrl"), false, -1, new Format(-1, FileType.UNKNOWN, AudioCodec.NONE, 0, false))
									.setFormatType(FormatType.DASH_STREAM));
				}
			}
			if (metadataCore.contains("storyboards")) {
				if (metadataCore.get("storyboards").contains("playerStoryboardSpecRenderer")) {
					String spec = metadataCore.mget("storyboards.playerStoryboardSpecRenderer").getString("spec", null);
					metadata.waitTillCompletion().setRawStoryboardURL(spec);
					if (spec != null) {
						try {
							final YouTubeStoryboard sb = YouTubeStoryboardParser.parse(spec);
							logger.trace("abi(5000): " + sb);
							metadata.waitTillCompletion().setStoryboardURL(sb.urlFor(sb.section(sb.getBestSectionSize())));
						} catch (Exception ex) {
							logger.error("Failed to decipher storyboard!", ex);
						}
					}
				} else if (metadataCore.get("storyboards").contains("playerLiveStoryboardSpecRenderer")) {
					String spec = metadataCore.mget("storyboards.playerLiveStoryboardSpecRenderer").getString("spec", null);
					metadata.waitTillCompletion().setRawStoryboardURL(spec);
					if (spec != null) {
						try {
							final YouTubeStoryboard sb = YouTubeStoryboardParser.parse(spec);
							logger.trace("abi(5000): " + sb);
							metadata.waitTillCompletion().setStoryboardURL(sb.urlFor(sb.section(sb.getBestSectionSize())));
						} catch (Exception ex) {
							logger.error("Failed to decipher storyboard!", ex);
						}
					}
				}
			}
			if (xNextJSON.waitFor(1, TimeUnit.SECONDS) != null) {
				try {
					final UniJsonValue xresult = xNextJSON.result();
					metadata.waitTillCompletion().updateLikeCount(getLikeCountString(xresult));
				} catch (Exception ex) {
					logger.error("Failed to get like count!", ex);
				}
			}
			if (objs != null) {
				try {
					final UniJsonValue o = xNextJSON.waitTillCompletion().get("contents").getEither("twoColumnWatchNextResults",
							"singleColumnWatchNextResults");
					if (o != null && o.hasValue("playlist")) {
						final UniJsonValue p = o.get("playlist").get("playlist");
						int currentIndex = p.getInt("currentIndex", -1);
						if (currentIndex == -1) {
							logger.error("Failed to track playlist index!");
						} else {
							logger.debug("current playlist index: " + currentIndex);
							pl.set(new Playlist(p.getString("title"), currentIndex));
							for (final UniJsonValue _v : p.get("contents").asArray()) {
								if (_v.hasValue("playlistPanelVideoRenderer")) {
									final UniJsonValue v = _v.get("playlistPanelVideoRenderer");
									final String _videoID = v.getString("videoId");
									final int index = v.get("navigationEndpoint").get("watchEndpoint").getInt("index");
									final String url = "/watch?v=" + _videoID + "&list="
											+ v.get("navigationEndpoint").get("watchEndpoint").getString("playlistId") + "&index=" + (index + 1);
									String title = "[FIXME] Unknown.";
									if (v.hasValue("title")) {
										title = getYTString(v.get("title"));
									}
									String author = "[FIXME] Unknown.";
									if (v.hasValue("longBylineText")) {
										author = getYTString(v.get("longBylineText"));
									}
									pl.object().addEntry(new PlaylistEntry(title, "https://i.ytimg.com/vi/" + _videoID + "/mqdefault.jpg", author, url, index));
								} else {
									continue;
								}
							}
							pl.object().makeCurrent(pl.object().at(currentIndex));
							try {
								if ((currentIndex + 1) >= pl.object().length()) {
									pl.object().makeNext(pl.object().at(0));
								} else {
									pl.object().makeNext(pl.object().at(currentIndex + 1));
								}
							} catch (Exception eo) {
								logger.error("Failed to get next playlist value!", eo);
							}
							logger.debug("Found playlist!");
						}
					}
				} catch (Exception ef) {
					logger.error("[playlistGen] Failed to get playlists!", ef);
				}
				try {
					metadata.waitTillCompletion().setChapters(SharedExtractorUtil.getChapters(xNextJSON.waitTillCompletion()));
				} catch (Exception ef) {
					logger.error("[chapterGen] Failed to get chapters!", ef);
				}
				try {
					UniJsonValue supertags = xNextJSON.waitTillCompletion().get("contents")
							.getEither("twoColumnWatchNextResults", "singleColumnWatchNextResults").mget("results.results.contents");
					if (supertags != null && !supertags.isNull()) {
						try {
							supertags = supertags.get(0).get("videoPrimaryInfoRenderer").get("superTitleLink").get("runs");
						} catch (Exception _ignored) {
						}
					}
					if (supertags != null && !supertags.isNull() && supertags.isArray()) {
						final List<String> supertags_entries = FastLists.list();
						supertags.asArray().forEach((tag) -> {
							final String text = tag.getString("text", "");
							if (!text.isEmpty() && !text.equals(" "))
								supertags_entries.add(text);
						});
						metadata.waitTillCompletion().setSupertags(supertags_entries);

					}
				} catch (Exception ef) {
					logger.error("[superTagGen] Failed to get supertags!", ef);
				}
			}
			YTCommentInitData cmdid = null;
			if (objs != null) {
				try {
					final UniJsonValue _response = xNextJSON.waitTillCompletion().getJSONObject("contents");
					final UniJsonValue twoColumnWatchNextResults = _response.getEither("twoColumnWatchNextResults", "singleColumnWatchNextResults")
							.getSafe("results").getSafe("results");
					final UniJsonValue contents = twoColumnWatchNextResults.getJSONArray("contents");
					// x[3].response.contents.twoColumnWatchNextResults.results.results.contents[2].itemSectionRenderer.continuations[0].nextContinuationData.continuation
					try {
						final UniJsonValue clarification = contents.first();
						if (clarification != null && !clarification.isNull() && isEnabled(arguments, MAP_TYPE_ENABLE_CLARIFICATIONS)) {
							if (clarification.get("itemSectionRenderer").get("contents").get(0).hasValue("clarificationRenderer")) {
								final UniJsonValue cr = clarification.get("itemSectionRenderer").get("contents").get(0).get("clarificationRenderer");
								final String title = getYTString(cr.get("contentTitle"));
								final String text = getYTString(cr.get("text"));
								final String subText = getYTString(cr.get("source"));
								final String url = cr.get("endpoint").get("urlEndpoint").getString("url");
								metadata.waitTillCompletion().addClarification(new Clarification(title, text, subText, url));
							}
						}
					} catch (Exception e1) {
						// ASSUME there are no clarifications.
					}
					try {
						final UniJsonValue commentMeta = contents.last().getJSONObject("itemSectionRenderer");
						final String cont = getYTContinuation(commentMeta);
						logger.debug("comment_cont: " + cont);
						if (cont != null) {
							final String clientName = clientType == YTIAPIClientType.TVHTML5_SIMPLE_EMBEDDED_PLAYER
									? YTIAPIClientType.WEB.get(YTIAPIClientEnums.ContextClientName)
									: clientType.get(YTIAPIClientEnums.ContextClientName);
							cmdid = new YTCommentInitData(videoID, cookiePayload, clientName, CLIENT_VERSION, cont,
									objs.getString("xsrf_token", YouTubeExtractorConstants.XSRF_TOKEN));
						}
					} catch (Exception e1) {
						// ASSUME comments are disabled.
					}
				} catch (Exception ex) {
					logger.error(ex);
				}
			}
			final Executable<?> metadata_fix_up = td.submit(() -> {
				try {
					final UniJsonValue _response = xNextJSON.waitTillCompletion().getJSONObject("contents");
					final UniJsonValue twoColumnWatchNextResults = _response.getEither("twoColumnWatchNextResults", "singleColumnWatchNextResults")
							.getSafe("results").getSafe("results");
					final UniJsonValue _contents = twoColumnWatchNextResults.getJSONArray("contents");
					try {
						final UniJsonValue _vpir = _contents.arrayGetContains("videoPrimaryInfoRenderer");
						if (_vpir != null && !_vpir.isNull()) {
							final UniJsonValue videoPrimaryInfoRenderer = _vpir.get("videoPrimaryInfoRenderer");
							metadata.waitTillCompletion().setLocalizedTitle(getYTString(videoPrimaryInfoRenderer.get("title")));
						} else {
							final UniJsonValue contents = _contents.get(0).get("slimVideoMetadataSectionRenderer").get("contents");
							final UniJsonValue vm = YTX_GET_IDENTIFIER(contents, "video_metadata.eml").get("componentType").get("model")
									.get("videoMetadataModel");
							final UniJsonValue videoMetadata = vm.get("videoMetadata");
							final UniJsonValue _title = videoMetadata.get("title");
							metadata.waitTillCompletion().setLocalizedTitle(getYTString(_title));
						}
					} catch (Exception ex) {
						logger.error("Failed to get localized video title!", ex);
					}
					if (!clientType.contains(YTIAPIClientEnums.IsMobileContext))
						return null;
					String str;
					try {
						final UniJsonValue videoPrimaryInfoRenderer = _contents.arrayGetContains("videoPrimaryInfoRenderer").get("videoPrimaryInfoRenderer");
						str = getYTString(videoPrimaryInfoRenderer.get("dateText"));
					} catch (Exception ex) {
						logger.error("Failed to use primary method to get upload date.");
						final UniJsonIterableValue smmsr = _contents.arrayGetContains("slimVideoMetadataSectionRenderer")
								.get("slimVideoMetadataSectionRenderer").get("contents").asArray();
						UniJsonValue desc = smmsr.last().get("slimVideoDescriptionRenderer");
						if (desc == null) {
							logger.warn("[metadata_fix_up] Failed to get slimVideoDescriptionRenderer.");
							return null;
						}
						final UniJsonValue publishDate = desc.get("publishDate");
						str = getYTString(publishDate).replace("Published on ", "").replace("Premiered on ", "");
					}
					metadata.waitTillCompletion().getExtendedMetadata().publishDate = str;
					if (metadata.waitTillCompletion().getExtendedMetadata().uploadDate == null)
						metadata.waitTillCompletion().getExtendedMetadata().uploadDate = "N/A";
				} catch (Exception ex) {
					logger.derror(ex);
				}
				return null;
			});
			// FIXME: Above won't work until we use the /next API call.
			final Executable<?> drm = td.submit(() -> {
				if (metadataCore.contains("streamingData") && metadataCore.get("streamingData").contains("licenseInfos")) {
					logger.debug("[DRM] Found DRM, attempting to generate metadata.");
					final UniJsonValue dr = metadataCore.get("streamingData");
					final UniJsonValue lis = dr.get("licenseInfos");
					final DRMMetadata.LicenseInfo[] lifs = new DRMMetadata.LicenseInfo[lis.length()];
					for (int i = 0; i < lis.length(); i++) {
						final UniJsonValue li = lis.get(i);
						lifs[i] = new DRMMetadata.LicenseInfo(li.getString("url"), li.getString("drmFamily"));
					}
					final DRMMetadata drmM = new DRMMetadata(dr.getString("drmParams"), dr.getString("dashManifestUrl"), lifs);
					metadata.waitTillCompletion().setDRMMetadata(drmM);
					if (metadataCore.contains("playerConfig") && metadataCore.get("playerConfig").contains("webDrmConfig")) {
						final UniJsonValue drmConfig = metadataCore.get("playerConfig").get("webDrmConfig");
						drmM.setUseInnertubeDrmService(drmConfig.getBoolean("useInnertubeDrmService"));
						drmM.setWidevineCertificate(drmConfig.getString("widevineServiceCert"));
					}
				}
				return null;
			});
			final Executable<YouTubeVideoHeatmap> heatmap = td.asyncSubmit(() -> {
				return getHeatmap(xNextJSON.waitTillCompletion(), disablePriorityPoint);
			});
			metadata.waitTillCompletion().setHeatmap(heatmap);
			if (useAlternativeMetadata != YTAlternativeMetadataType.NONE) {
				metadata.waitTillCompletion().addClarification(
						new Clarification("Preview.", "The video you are viewing is a preview.", "nTube does not support paid movie playback.", null));
			}
			if (useAlternativeMetadata != YTAlternativeMetadataType.NONE && alternateMetadataStartTime > -1) {
				final String times = TimeUtil.formatSecondsL(alternateMetadataStartTime - System.currentTimeMillis() / 1000);
				metadata.waitTillCompletion()
						.addClarification(new Clarification("Upcoming content.", "This video or stream will be live in " + times + " from now.",
								"Stream Start: " + Dates.time((long) (alternateMetadataStartTime * 1000)).toString() + "", null));
				metadata.waitTillCompletion().setContentStartTime((long) (alternateMetadataStartTime * 1000));
			}
			td.await(drm);
			td.await(metadata_fix_up);
			return new ExtractedVideo(results, metadata.waitTillCompletion().setEndscreenData(endscreen.waitTillCompletion()).setVR180(isVR180.get()),
					ExtractionResult.SUCCESS).setAdvertisingMetadata(advertisingMetadata.waitTillCompletion()).setLateExtraction(allowLateExtractions)
					.setLateExtractionHold(allowLateExtractions ? td.asyncAwait(queue) : null).setPlaylist(pl.object()).setClientType(clientType)
					.setCommentInitData(cmdid);
		});

	}

	@Override
	public Object getFlag(final int flag) {
		switch (flag) {
			case FLAG_EXTRACTOR_SUPPORTS_AGE_GATE:
				return FLAG_TRUE;
			case FLAG_EXTRACTOR_SUPPORTS_LIVESTREAM_CHAT:
			case FLAG_EXTRACTOR_SUPPORTS_LIVESTREAMS:
			case FLAG_EXTRACTOR_SUPPORTS_LATE_EXTRACTION:
			case FLAG_EXTRACTOR_SUPPORTS_HEATMAPS:
				return FLAG_TRUE;
			case FLAG_EXTRACTOR_SUPPORTS_SEGMENTED_EXTRACTION:
				return FLAG_FALSE;
			case FLAG_EXTRACTOR_SUPPORTS_ABI_2_2:
				return FLAG_TRUE;
			case FLAG_EXTRACTOR_GET_SUPPORTED_ABI:
				return 2.2f;
			default:
				return FLAG_NOT_SUPPORTED;
		}
	}

	@Override
	public String getID() {
		return "ytiapi.native-rolling";
	}

	@Override
	public int abiVersion() {
		return 6010;
	}

	private static final ABIFeatures[] ABI_FEATURES =
		{ ABIFeatures.VIDEO_EXTRACTION, ABIFeatures.VIDEO_BASIC_METADATA, ABIFeatures.VIDEO_PLAYLIST_SUPPORT, ABIFeatures.VIDEO_RELATED_CONTENT_SUPPORT,
				ABIFeatures.VIDEO_FALLBACK_EMBED, ABIFeatures.VIDEO_CONTROVERSY_BYPASS,

				ABIFeatures.VIDEO_DECIPHERING, ABIFeatures.VIDEO_CAPTIONS_BASIC, ABIFeatures.VIDEO_CAPTIONS_FULL, ABIFeatures.VIDEO_PLAYLIST_ENHANCED,
				ABIFeatures.VIDEO_LIKE_DISLIKE_RATIO,

				ABIFeatures.VIDEO_360_CONTENT, ABIFeatures.VIDEO_DECIPHERER_AUTO, ABIFeatures.VIDEO_UPCOMING_VIDEO_HANDLE, ABIFeatures.VIDEO_LIVESTREAMS,
				ABIFeatures.VIDEO_MULTI_TYPE_CIPHERS, ABIFeatures.VIDEO_ENDSCREEN,

				ABIFeatures.COMMENTS_FULL_SUPPORT, ABIFeatures.EXTRACTOR_MULTI_THREADING, ABIFeatures.EXTRACTOR_NO_HTML_PARSING,
				ABIFeatures.EXTRACTOR_V4_THREADING, ABIFeatures.CLARIFICATIONS, ABIFeatures.CHAPTER_SUPPORT, ABIFeatures.SUPER_TAG_SUPPORT, /*
																																			 * FIXME: ???, not
																																			 * sure lol
																																			 */

				ABIFeatures.INLINE_VIDEO_THUMBNAILS, ABIFeatures.INLINE_VIDEO_THUMBNAILS_V2, ABIFeatures.INLINE_RAW_VIDEO_THUMBNAILS,
				ABIFeatures.THUMBNAIL_SUPPORT_LEVEL, ABIFeatures.RAW_CAPTION_URLS, ABIFeatures.CUSTOM_THUMBNAIL_URLS, ABIFeatures.POST_EXTRACTION_PROCESSING,
				ABIFeatures.EXTENDED_CODEC_METADATA, ABIFeatures.DRM_METADATA,

				ABIFeatures.YTE_API_2_2_SUPPORT, ABIFeatures.VIDEO_PLAYBACK_MODE,

				ABIFeatures.MULTI_CLIENT_SUPPORT, ABIFeatures.VIDEO_FLAGS, ABIFeatures.VIDEO_HEATMAP, };

	@Override
	public ABIFeatures[] abiProvides() {
		return ABI_FEATURES;
	}

}
